Shiny Application 만들어보기

Author

김세창, 김우형, 이상일

R과 Shiny로 만드는 웹 애플리케이션: 탐구학습용 학습도구의 개발

1. Shiny란 무엇인가요? 🤔

Shiny는 R 언어로 웹 앱을 손쉽게 만드는 멋진 도구입니다. 여기서 앱(Application)은, 아래의 I-P-O 구조를 갖는다는 뜻입니다.

  • Input (입력): 사용자가 조작할 수 있는 버튼, 슬라이더, 텍스트 상자 같은 위젯이에요.

  • Processing (처리): 입력값에 따라 자동으로 계산이나 분석을 해 주는 ‘뇌’ 역할이에요.

  • Output (출력): 처리된 결과를 화면에 보여주는 그래프, 표, 텍스트 등입니다.

이렇게 세 부분이 상호 연결되어, 사용자가 값을 바꾸면 서버가 재빠르게 계산해서 결과를 갱신해 줍니다.

2. Shiny App의 구조

I-P-O 중, 우리 눈에 보이는 부분은 Input과 Output입니다. 그리고 Process는 눈에 보이지 않고, 내부적으로 작동하죠. 눈에 보이는 부분을 Shiny에서는 ui(user interface)라고 하는 객체에 할당합니다. 그리고 눈에 보이지 않는 Process는 server라는 객체에 할당하죠. 할당이 끝나면 shinyApp(ui, server)를 입력해 애플리케이션을 작동시킵니다.

  • UI 객체: input & output

  • server 객체: Process

  • 실행: ShinyApp(ui, server)

3. UI 만들기 🎫

먼저 우리는 bslib 패키지가 제공하는 page_navbar() 함수로 UI를 만들 것입니다. 첫째, page_navbar() 안에 여러 개의 nav_panel()을 사용하면, 상단 네비게이션 바에 탭(페이지)을 손쉽게 추가할 수 있어요.

  • nav_panel(title = "탭 제목", ...) 형태로, title에 문자열을 지정하면 해당 탭이 생성됩니다.
library(shiny)
library(bslib)

# UI
ui <- page_navbar(
  title = "TEST PAGE", 
  theme = bs_theme(version = 5),
  
  nav_panel(
    title = "1번 탭"
  )
)

# Server
server <- function(input, output, session){}

# 앱 실행
shinyApp(ui = ui, server = server)

3.1. sidebarLayout으로 사이드바 + 메인 영역 만들기 🗂️

nav_panel() 안에 sidebarLayout()을 넣으면, 사이드바 패널메인 패널 구조를 간단하게 구축할 수 있습니다.

  • sidebarPanel()에는 입력 위젯을, mainPanel()에는 출력 위젯을 배치합니다.

사용자 경험(UX) 측면에서 설정과 결과를 분리하여 보여줄 때 유용해요. 이번 실습에서는 sidebarPanelmainPanel을 적극 활용하도록 하겠습니다.

library(shiny)
library(bslib)

# UI
ui <- page_navbar(
  title = "TEST PAGE", 
  theme = bs_theme(version = 5),
  
  nav_panel(
    title = "1번 탭",
    sidebarLayout(
      sidebarPanel(
        h4("입력값")
      ),
      mainPanel(
        h4("출력값")
      )
    )
  )
)

# Server
server <- function(input, output, session){}

# 앱 실행
shinyApp(ui = ui, server = server)

3.2. fluidRow와 column으로 행과 열 나누기 📐

fluidRow()column()을 사용하면, 화면을 행(row) 단위로 나누고, 그 안에서 열(column) 폭을 12단계 그리드로 설정해 세부 레이아웃을 조정할 수 있습니다.

  • column(width = 6, ...)는 전체 폭의 절반을 의미합니다.

여러 column()을 한 행에 배치하면, 원하는 비율로 콘텐츠를 배치할 수 있어요.

library(shiny)
library(bslib)

# UI
ui <- page_navbar(
  title = "TEST PAGE", 
  theme = bs_theme(version = 5),
  
  nav_panel(
    title = "1번 탭",
    sidebarLayout(
      sidebarPanel(
        h4("입력값")
      ),
      mainPanel(
        fluidRow(
          column(
            width = 6,
            p("출력값 1")
          ),
          column(
            width = 6,
            p("출력값 2")
          )
        )
      )
    )
  )
)

# Server
server <- function(input, output, session){}

shinyApp(ui, server)

3.3. 통합 예제 코드

아래는 탭 3개를 가진 기본 앱으로, 각 탭에서 화면 구성을 다르게 보여 줍니다.

library(shiny)
library(bslib)


ui <- page_navbar(
  title = "나의 Shiny 앱",
  theme = bs_theme(version = 5),

  # 1) 홈 탭: 간단한 텍스트
  nav_panel(
    title = "홈",
    p("홈 페이지입니다. 여기에 앱 소개나 설명을 넣어 보세요.")
  ),
  
  # 2) 데이터 탭: fluidRow + column 사용
  nav_panel(
    title = "데이터",
    fluidRow(
      column(width = 6,
        h4("왼쪽 열"),
        p("이곳에 그래프나 테이블을 넣을 수 있습니다.")
      ),
      column(width = 6,
        h4("오른쪽 열"),
        p("이곳에는 설명 문구나 다른 출력 요소를 배치해 보세요.")
      )
    )
  ),

  # 3) 분석 탭: 사이드바 + 메인 영역
  nav_panel(
    title = "분석",
    sidebarLayout(
      sidebarPanel(
        sliderInput(
          inputId = "num",
          label   = "숫자를 선택하세요:",
          min     = 1,
          max     = 10,
          value   = 5
        )
      ),
      mainPanel(
        verbatimTextOutput(outputId = "square")
      )
    )
  )
)

server <- function(input, output, session) {
  # 분석 탭의 출력: 입력한 숫자의 제곱 계산
  output$square <- renderText({
    paste0("입력값: ", input$num,
           " → 제곱: ", input$num^2)
  })
}

# 앱 실행
shinyApp(ui = ui, server = server)

실습 방법

  1. 위 코드를 app.R에 복사+붙여넣기 하고 저장하기
  2. Run App을 눌러 실행
  3. 상단 탭을 클릭해 각 레이아웃(텍스트, 사이드바, 다중 열)이 제대로 표시되는지 확인하기
  4. 글자 바꿔보면서 구조 파악하기

이제 nav_panel()으로 탭을 만들고, 각 탭 안에서 다양한 레이아웃 함수를 활용하는 방법을 익혔습니다.

4. UI와 Server의 연결

Shiny 앱의 핵심은 UI에서 입력된 값이 서버를 거쳐 결과로 돌아오는 과정이에요. 이 챕터에서는 그 과정을 4단계로 나누어 아주 쉽게 설명해 볼게요.

4.1. 입력 위젯에서 ID 부여받기

  • UI 코드 예시:

    sliderInput(inputId = "obs",
                label = "관측치 개수:",
                min = 1,
                max = 100,
                value = 50)
  • 첫 번째 인수("obs")가 바로 ID예요.

이 ID 덕분에 서버가 사용자가 슬라이더에서 고른 값을 알 수 있어요.

library(shiny)
library(bslib)

# UI
ui <- page_navbar(
  title = "TEST PAGE", 
  theme = bs_theme(version = 5),
  
  nav_panel(
    title = "1번 탭",
    sidebarLayout(
      sidebarPanel(
        h4("입력값"),
        sliderInput(inputId = "obs",
                    label = "관측치 개수:",
                    min = 1,
                    max = 100,
                    value = 50)
      ),
      mainPanel(
        fluidRow(
          column(
            width = 6,
            p("출력값 1")
          ),
          column(
            width = 6,
            p("출력값 2")
          )
        )
      )
    )
  )
)

# Server
server <- function(input, output, session){}

shinyApp(ui, server)

4.2. 서버에서 입력값 읽기: input$id

이제 Server를 한 번 건드려보겠습니다. UI에서 “obs”라는 id를 부여받은 입력값은, Server에서 input$obs로 인식되게 됩니다.

  • 서버 함수 안에서 다음과 같이 입력값을 가져옵니다:

    input$obs
  • input$obs는 사용자가 슬라이더를 움직일 때마다 자동 갱신되는 리액티브 값이에요.

4.3. 처리 함수 거쳐서 output$y 만들기

  • 이제 입력값인 input$obs 를 가지고 server는 어떤 처리(process)를 진행합니다.

  • 예를 들어 관측값에 3을 곱하거나, 10을 더할 수 있겠죠?

  • server는 render*() 함수로 결과를 계산하고, 이 결과를 output 객체에 저장해요.

  • 예시:

output$multiply <- renderText({
    input$obs*3
  })
  • renderText() 안에 input$obs를 사용해 곱셈을 하고,
  • 그 결과를 multiply라는 이름(output$multiply)으로 저장합니다.

4.4. UI에서 출력 위젯으로 보여주기

  • 이제 Server에서 계산한 output 값을 다시 UI로 보내주어야 합니다.

  • 이 때, Server에서 계산한 값의 형태에 따라, UI에서 대응되는 함수가 조금씩 달라집니다. 예를 들어, 문자 형태라면 textOutput() 이라는 함수를 UI에 써줍니다.

    textOutput("multiply")
  • 그림 형태라면 plotOutput() 이라는 함수를, 표 형태라면 tableOutput() 이라는 함수를 써주어야 합니다.

library(shiny)
library(bslib)

# UI
ui <- page_navbar(
  title = "TEST PAGE", 
  theme = bs_theme(version = 5),
  
  nav_panel(
    title = "1번 탭",
    sidebarLayout(
      sidebarPanel(
        h4("입력값"),
        sliderInput(inputId = "obs",
                    label = "관측치 개수:",
                    min = 1,
                    max = 100,
                    value = 50)
      ),
      mainPanel(
        fluidRow(
          column(
            width = 6,
            textOutput("multiply")
          ),
          column(
            width = 6,
            textOutput("add")
          )
        )
      )
    )
  )
)

# Server
server <- function(input, output, session){
  output$multiply <- renderText({
    input$obs*3
  })
  output$add <- renderText({
    input$obs+10
  })
}

shinyApp(ui, server)

4.5. 전체 흐름 요약

  1. UI: sliderInput("obs", ...) → ID = obs 부여
  2. Server 입력: input$obs로 값 읽기
  3. Server 처리: output$distPlot <- renderPlot({ … }) 으로 결과 저장
  4. UI 출력: plotOutput("distPlot")로 화면에 표시

이 4단계를 이해하면, Shiny 앱의 기본 메커니즘을 탄탄하게 잡을 수 있습니다.

참고!

UI에서 Input을 만드는 함수는 sliderInput 외에도 여러 가지가 있습니다. 또한 Server에서 Render 함수의 종류에 상응하는 UI에서의 출력 함수가 정해져 있습니다. 예를 들어, Server에서 그림, 즉 Plot을 만들기 위해서는 Server에서 renderPlot() 함수를 써야하며, UI에서는 plotOutput() 함수를 써야 합니다. 아래의 표를 참고해보세요!

UI 입력 위젯 서버 (input → render → output) UI 출력 위젯
sliderInput("obs", "관측치 개수:", 1,100,50) output$distPlot <- renderPlot({ hist(rnorm(input$obs)) }) plotOutput("distPlot")
textInput("caption", "제목 입력:", "안녕하세요") output$captionText <- renderText({ input$caption }) textOutput("captionText")
selectInput("species", "종 선택:", choices) output$speciesTable <- renderTable({ subset(data, species==input$species) }) tableOutput("speciesTable")
numericInput("num", "숫자 입력:", 10, step=1) output$numPrint <- renderPrint({ input$num }) verbatimTextOutput("numPrint")
dateInput("date", "날짜 선택:", Sys.Date()) output$dateText <- renderText({ format(input$date, "%Y-%m-%d") }) textOutput("dateText")

아래의 코드를 보고, UI에서 입력 데이터를 어떻게 부여하는지, 이것이 Server에서 어떻게 프로세스를 거쳐 결과값으로 바뀌는지, 마지막으로 그 결과값이 어떻게 다시 UI로 출력되는지 살펴보시기 바랍니다. 시간을 10분 정도 드릴테니, 꼼꼼하게 뜯어보시기 바랍니다.

library(shiny)
library(bslib)
library(ggplot2)
library(dplyr)

# UI
ui <- page_navbar(
  title = "TEST PAGE", 
  theme = bs_theme(version = 5),
  
  nav_panel(
    title = "1번 탭",
    sidebarLayout(
      sidebarPanel(
        h4("입력값"),
        sliderInput(inputId = "obs",
                    label = "관측치 개수:",
                    min = 1,
                    max = 100,
                    value = 50),
        textInput("name", "이름 입력:", "홍길동"),
        selectInput("brand",
                    "제조사를 고르세요:",
                    unique(mpg$manufacturer)),
        radioButtons("class", "종류를 고르세요:",
                     unique(mpg$class))
      ),
      mainPanel(
        fluidRow(
          column(
            width = 6,
            textOutput("multiply")
          ),
          column(
            width = 6,
            tableOutput("table")
          )
        ),
        fluidRow(
          column(
            width = 6,
            textOutput("hello")
          ),
          column(
            width = 6,
            plotOutput("graph")
          )
        )
      )
    )
  )
)

# Server
server <- function(input, output, session){
  output$multiply <- renderText({
    input$obs*3
  })
  output$add <- renderText({
    input$obs+10
  })
  output$hello <- renderText({
    paste0("안녕하세요, 제 이름은 ", input$name, "입니다.")
  })
  output$graph <- renderPlot({
    mpg |> filter(manufacturer == input$brand) |> 
      ggplot(aes(x=cty, y=hwy))+
      geom_point() +
      theme_bw()
  })
  output$table <- renderTable({
    mpg |> 
      filter(class == input$class) |> 
      head(10) |> 
      select(-c(trans, year, displ, fl, class))
      
  })
}

shinyApp(ui, server)

5. 예제

이제 앞서 배웠던 위성영상 데이터 분석을 적용해보겠습니다.

library(shiny)
library(bslib)
library(terra)
library(sf)
library(dplyr)

# SHP Seoul File
seoul <- st_read("data/SIGUN.shp", options = "ENCODING=CP949") |> 
  filter(SG1_NM == "서울특별시")

# Mask Raster File
bands_stack <- rast("data/landsat_seoul.tif") |> 
  mask(seoul)

# UI
ui <- page_navbar(
  title = "Landsat Image Composite", 
  theme = bs_theme(version = 5),
  
  nav_panel(
    "Composite",
    sidebarLayout(
      sidebarPanel(
        h4("Select Bands for Composite"),
        selectInput(
          inputId = "red_band",
          label = "Red Channel",
          choices = names(bands_stack),
          selected = "B4_Red"
        ),
        selectInput(
          inputId = "green_band",
          label = "Green Channel",
          choices = names(bands_stack),
          selected = "B3_Green"
        ),
        selectInput(
          inputId = "blue_band",
          label = "Blue Channel",
          choices = names(bands_stack),
          selected = "B2_Blue"
        )
      ),
      
      mainPanel(
        plotOutput("composite_map", height = "600px"),
      )
    )  
  ),    # nav_panel 닫기
  
  nav_panel(
    "Threshold",
    sidebarLayout(
      sidebarPanel(
        h4("Threshold Range"),
        selectInput(
          inputId = "sel_band",
          label = "Select the Band",
          choices = names(bands_stack),
          selected = "B4_Red"
        ),
        sliderInput(
          inputId = "threshold",
          label = "Value Range",
          min = 0,
          max = 20000,
          value = 100,
          step = 100
        )
      ),
      
      mainPanel(
        plotOutput("NDVI_masked", height = "600px")
      )
    )  # sidebarLayout 닫기
  ) 
)      # page_navbar 닫기


# Server 정의
server <- function(input, output, session) {
  
  # 선택한 밴드로 합성 래스터 생성
  composite_raster <- reactive({
    sel <- c(input$red_band,
             input$green_band,
             input$blue_band)
    bands_stack[[sel]]
  })
  
  select_raster <- reactive({
    selc <- input$sel_band
    bands_stack[[selc]]
  })
  
  # Leaflet에 RGB 또는 다중 밴드 합성 결과 그리기
  output$composite_map <- renderPlot({
    comp <- composite_raster()
    plotRGB(comp, r = 1, g = 2, b = 3,
            stretch = "lin")
  })
  
  # NDVI 또는 첫 번째 밴드 마스크 후 플롯
  output$NDVI_masked <- renderPlot({
    req(input$sel_band, input$threshold)
    rstr   <- select_raster()
    mask_r <- terra::mask(
      rstr,
      rstr < input$threshold,
      maskvalues=TRUE)
    plot(mask_r,
         col = rev(terrain.colors(10)),
         main="Masked Raster",
         axes=FALSE,
         box=FALSE)
  })
}

# 앱 실행
shinyApp(ui = ui, server = server)

6. 마치며…

이른 시간 안에 Shiny를 배우는 것은 사실 어려운 일입니다. 그러나 ChatGPT 등의 AI 도구가 발전함에 따라, 간단한 코딩은 정말 간단한 일이 되고 있습니다. 바이브 코딩(Vibe Coding)이 최근 화제가 되고 있는 이유이기도 합니다.

그러나 AI가 멋진 결과물을 만들어주기 위해서는, 질문자의 사전 지식도 매우 중요합니다. 얼마나 구체적인 질문을 하느냐에 따라 답변의 퀄리티가 완전히 달라지기 때문입니다.

오늘 학습 내용(원격탐사에 대한 이론적 지식, R, Quarto, Shiny 등의 기술적 지식)을 통해, 선생님들께서는 훌륭한 질문을 할 수 있는 사전 지식을 쌓았을 것이라고 믿습니다. 이를 바탕으로, 지금부터는 각자의 조로 이동해 집단 지성과 AI, 그리고 멘토의 도움을 통해 멋진 학습 도구를 만들어보시기 바랍니다!